在 Day 01 先瞭解到既使現在 AI 發展突飛猛進的時候,程式開發工程師還是必須要瞭解測試的基礎,並且要清楚知道 FIRST 原則和測試金字塔的重要性。
主要的一個原因是,我們要能夠有「識讀」測試程式碼的能力,必須要知道測試程式碼是要測什麼情境、要測什麼功能,以及要驗證什麼結果。
不能因為什麼都是 AI 代為產生,然後執行全部都是綠燈就當全部驗證通過,這就好比一堆人還以為「程式編譯成功就認為程式沒有錯」一樣荒謬。
Day 02 我們要進入實戰階段:選擇合適的測試框架並建立第一個真正的測試專案。
在 .NET 生態系統中,測試框架的選擇會影響你未來幾年的開發體驗。一個好的測試框架不僅要語法簡潔,更要有良好的生態支援、活躍的社群,以及與現代開發工具的深度整合。
為什麼我推薦 xUnit?
作為一個從一開始就使用 MSTest 寫測試的老派工程師,其實我一直以來都堅持 MSTest 來開發測試程式,不外乎就是因為它很好上手,甚至於之後在團隊裡推廣與教學都是使用 MSTest。
直到 StackOverflow 所找到有關測試的內容都已經沒有什麼 MSTest 相關的問答,幾乎都是 xUnit 或 NUnit 比較多,然後到了 .NET Core 發佈之後,.NET 團隊自己都是用 xUnit 為測試框架,然後多數的開源套件的測試也都使用 xUnit,所以就讓我覺得是該要換一個測試框架,於是就跟著多數人的選擇而改用 xUnit。
xUnit 不僅是技術上的先進,更重要的是它的設計相當契合現代軟體開發的需求。
注意!今天的內容蠻多的。
在 .NET 生態系統中,主要有三大測試框架:
特性 | xUnit | NUnit | MSTest |
---|---|---|---|
建立時間 | 2007 年 | 2002 年 | 2005 年 |
設計概念 | 簡潔、現代 | 功能豐富 | Visual Studio 整合 |
測試隔離 | 預設隔離 | 需要設定 | 需要設定 |
並行執行 | 原生支援 | 支援 | 支援 |
參數化測試 | Theory/InlineData | TestCase | DataRow |
社群活躍度 | 非常活躍 | 活躍 | 一般 |
開源狀況 | 完全開源 | 完全開源 | 部分開源 |
現代 .NET 支援 | 優秀 | 良好 | 良好 |
關於測試隔離:測試隔離是選擇測試框架的重要考量因素之一。xUnit 預設提供完整的測試隔離機制,而 NUnit 和 MSTest 則需要額外設定。詳細的測試隔離說明和實際範例,請參考本文後面的「測試隔離深度解析」章節。
// xUnit:清晰、直接
[Fact]
public void Add_輸入1和2_應回傳3()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
Assert.Equal(3, result);
}
// NUnit:功能豐富但較複雜
[Test]
public void Add_輸入1和2_應回傳3()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
Assert.AreEqual(3, result);
}
// MSTest:與 Visual Studio 深度整合
[TestMethod]
public void Add_輸入1和2_應回傳3()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
Assert.AreEqual(3, result);
}
xUnit 的每個測試方法都會創建新的測試類別實例,這確保了每個測試都有乾淨的執行環境,完全不會受到其他測試的影響。
public class CalculatorTests
{
private readonly Calculator _calculator;
// 每個測試都會執行這個建構函式,獲得全新的實例
public CalculatorTests()
{
_calculator = new Calculator();
}
[Fact]
public void Test1_會有新的_calculator實例()
{
// 這裡的 _calculator 是全新的
}
[Fact]
public void Test2_也會有新的_calculator實例()
{
// 這裡的 _calculator 也是全新的,與 Test1 完全無關
}
}
為什麼這很重要?
老派工程師的實務經驗:
在我過往使用 MSTest 的時期,經常遇到因為測試類別共用狀態設計不當而導致測試間相互影響的問題。一個測試失敗可能會連帶影響其他測試,這在大型專案中是非常頭痛的問題。xUnit 從設計上就避免了這個陷阱。
// xUnit Theory:優雅且強大
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_各種輸入組合_應回傳正確結果(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
Assert.Equal(expected, result);
}
// 對比 NUnit TestCase
[TestCase(1, 2, 3)]
[TestCase(-1, 1, 0)]
[TestCase(0, 0, 0)]
public void Add_各種輸入組合_應回傳正確結果(int a, int b, int expected)
{
// 類似的語法,但 xUnit 的 Theory 概念更清晰
}
xUnit 在現代 .NET 開發中的支援度:
值得注意的是,目前有三個主要的 .NET 測試框架:
我在不同專案中使用過的測試框架:
MSTest 時期 (2014-2022):
xUnit 時期 (2022 - 現在):
結論:對於新專案,我強烈推薦 xUnit。
以下是一個使用 xUnit 的基本測試類別
using NSubstitute;
using xUnitSample.Misc;
namespace xUnitSample
{
public class FactTests : IDisposable
{
private readonly IRepository _sut;
public FactTests()
{
// 建構式
// 初始化相依物件
_sut = Substitute.For<IRepository>(); // NSubstitute ... 之後會講到
}
public void Dispose()
{
// 想要清除的東西
// 非必要
// 如果不需要的話,可以不用繼承 IDisposable
}
[Fact]
public void TestMethod()
{
// Fact 範例
Assert.True(true);
}
[Fact]
[Trait("Category", "FactTests.TestMethodTrait")]
public void TestMethodTrait()
{
Assert.True(true);
}
[Fact(Skip = "忽略的理由")]
public void TestMethodSkip()
{
// Skip 範例
Assert.True(true);
}
}
}
在 xUnit 測試框架中,測試類別的建構式(Constructor)主要用於初始化測試所需的資源和狀態。每次執行測試方法時,xUnit 都會創建一個新的測試類別實例,因此建構式會在每個測試方法執行前被調用。
主要用途
在 xUnit 測試框架中,Dispose 方法主要用於在每個測試方法執行後進行清理工作。這是通過實現 IDisposable 介面來完成的。當測試類別實現 IDisposable 接口時,xUnit 會在每個測試方法執行完畢後自動調用 Dispose 方法。
主要用途
注意:每個單元測試方法的執行都是獨立的
單元測試都是個別獨立、無順序性、執行結果不會影響其他測試
所以當執行一個單元測試方法的時候,都會先去執行建構式,然後執行測試方法,最後再去執行 Dispose 方法
xUnit 提供了多種屬性(Attributes)來幫助編寫和組織單元測試。以下是一些常用的 xUnit 屬性及其功能:
[Fact]
public void Calculate_固定輸入_應回傳預期結果()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(5, 3);
// Assert
Assert.Equal(8, result);
}
Fact 適用於:
[Theory]
[InlineData(1, 2, 3)]
[InlineData(5, 7, 12)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_多組輸入_應回傳正確結果(int a, int b, int expected)
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
Theory 適用於:
Theory 不只是參數化測試,它代表了一種測試思維:
// 單一 Theory 實際上執行了 4 個測試案例
[Theory]
[InlineData("test@example.com", true)] // 有效 email
[InlineData("invalid-email", false)] // 無效格式
[InlineData("", false)] // 空字串
[InlineData(null, false)] // null 值
public void IsValidEmail_各種輸入_應回傳正確驗證結果(string email, bool expected)
{
var validator = new EmailValidator();
var result = validator.IsValidEmail(email);
Assert.Equal(expected, result);
}
測試結果會顯示:
通過 IsValidEmail_各種輸入_應回傳正確驗證結果(email: "test@example.com", expected: True)
通過 IsValidEmail_各種輸入_應回傳正確驗證結果(email: "invalid-email", expected: False)
通過 IsValidEmail_各種輸入_應回傳正確驗證結果(email: "", expected: False)
通過 IsValidEmail_各種輸入_應回傳正確驗證結果(email: null, expected: False)
除了 InlineData 之外,xUnit 還提供了更強大的資料提供機制:
MemberData:使用靜態屬性或方法提供測試資料
public class CalculatorAdvancedTests
{
private readonly Calculator _calculator;
public CalculatorAdvancedTests()
{
_calculator = new Calculator();
}
// 使用靜態屬性提供測試資料
public static IEnumerable<object[]> AddTestData =>
new List<object[]>
{
new object[] { 1, 2, 3 },
new object[] { -1, 1, 0 },
new object[] { 0, 0, 0 },
new object[] { 100, 200, 300 },
new object[] { -5, -3, -8 }
};
[Theory]
[MemberData(nameof(AddTestData))]
public void Add_使用MemberData_應回傳正確結果(int a, int b, int expected)
{
// Act
var result = _calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
}
ClassData:使用專門的類別提供測試資料
public class DivisionTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { 10, 2, 5.0 };
yield return new object[] { 7, 2, 3.5 };
yield return new object[] { -10, 2, -5.0 };
yield return new object[] { 0, 5, 0.0 };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(DivisionTestData))]
public void Divide_使用ClassData_應回傳正確結果(int dividend, int divisor, double expected)
{
// Act
var result = _calculator.Divide(dividend, divisor);
// Assert
Assert.Equal(expected, result, precision: 1);
}
什麼時候使用哪種方式?
Trait 用於標記測試的分類或特性,便於篩選和組織測試:
[Fact]
[Trait("Category", "Unit")]
[Trait("Priority", "High")]
public void Add_基本功能_應正確執行()
{
// 測試邏輯
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Component", "Database")]
public void SaveUser_整合測試_應成功儲存()
{
// 整合測試邏輯
}
在 Visual Studio 的測試總管中,你可以使用 Trait 來過濾測試,例如只執行 Category 為 "Unit" 的測試。
當你需要暫時忽略某個測試時:
[Fact(Skip = "等待外部 API 修復後再啟用")]
public void CallExternalApi_應回傳正確資料()
{
// 這個測試會被跳過,但會在測試報告中顯示跳過原因
}
[Theory]
[InlineData(1, 2, 3)]
[Skip = "重構中,暫時停用"]
public void MethodUnderRefactoring_測試案例(int a, int b, int expected)
{
// 暫時停用的測試
}
被 Skip 的測試會在測試總管中以黃色警告圖示顯示,並顯示跳過的原因。
xUnit 提供了三種層級的生命週期管理,每種都有不同的適用場景:
public class OrderServiceTests
{
private readonly OrderService _orderService;
private readonly IRepository<Order> _mockRepository;
// 每個測試方法執行前都會呼叫
public OrderServiceTests()
{
_mockRepository = Substitute.For<IRepository<Order>>();
_orderService = new OrderService(_mockRepository);
// 可以在這裡設定共用的測試資料
Console.WriteLine("測試開始:建立新的服務實例");
}
}
生命週期:
重要概念:每個單元測試方法的執行都是獨立的。單元測試都是個別獨立、無順序性、執行結果不會影響其他測試。所以當執行一個單元測試方法的時候,都會先去執行建構式,然後執行測試方法,最後再去執行 Dispose 方法。
public class DatabaseTests : IDisposable
{
private readonly TestDatabase _testDatabase;
public DatabaseTests()
{
_testDatabase = new TestDatabase();
_testDatabase.Initialize();
}
[Fact]
public void SaveUser_應成功儲存到資料庫()
{
// 測試邏輯
}
// 每個測試方法執行後都會呼叫
public void Dispose()
{
_testDatabase?.Cleanup();
Console.WriteLine("測試結束:清理測試資料");
}
}
有些資源 (如資料庫連線、外部服務) 的建立成本很高,不適合每個測試都重新建立:
// 共享的測試資源
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; private set; }
public DatabaseFixture()
{
// 建立測試資料庫(只會執行一次)
ConnectionString = CreateTestDatabase();
}
public void Dispose()
{
// 清理測試資料庫(所有測試完成後執行一次)
CleanupTestDatabase();
}
private string CreateTestDatabase() => "test-connection-string";
private void CleanupTestDatabase() { /* 清理邏輯 */ }
}
// 使用共享資源的測試類別
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _databaseFixture;
public UserRepositoryTests(DatabaseFixture databaseFixture)
{
_databaseFixture = databaseFixture;
}
[Fact]
public void SaveUser_應儲存到共享的測試資料庫()
{
var repository = new UserRepository(_databaseFixture.ConnectionString);
// 測試邏輯
}
}
建立一個符合最佳實踐的測試專案結構:
MyProject/
├── src/
│ └── MyProject.Core/
│ ├── MyProject.Core.csproj
│ ├── Calculator.cs
│ ├── EmailValidator.cs
│ └── OrderService.cs
├── tests/
│ └── MyProject.Core.Tests/
│ ├── MyProject.Core.Tests.csproj
│ ├── CalculatorTests.cs
│ ├── EmailValidatorTests.cs
│ └── OrderServiceTests.cs
└── MyProject.sln
# 建立解決方案
dotnet new sln -n MyProject
# 建立主專案
dotnet new classlib -n MyProject.Core -o src/MyProject.Core
# 建立測試專案
dotnet new xunit -n MyProject.Core.Tests -o tests/MyProject.Core.Tests
# 將專案加入解決方案
dotnet sln add src/MyProject.Core/MyProject.Core.csproj
dotnet sln add tests/MyProject.Core.Tests/MyProject.Core.Tests.csproj
# 建立專案參考關係
dotnet add tests/MyProject.Core.Tests/MyProject.Core.Tests.csproj reference src/MyProject.Core/MyProject.Core.csproj
操作畫面
建立新的解決方案
加入主專案
加入測試專案
設定專案參考
執行指令
# 在你的 xUnit 測試專案 中加入 coverlet.collector 套件,以便進行 Code Coverage 收集
dotnet add package coverlet.collector
操作畫面
查看 MyProject.Core.Tests.csproj
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyProject.Core\MyProject.Core.csproj" />
</ItemGroup>
</Project>
在 MyProject.Core
專案中建立 Calculator.cs
:
namespace MyProject.Core;
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
public int Multiply(int a, int b)
{
return a * b;
}
public double Divide(int dividend, int divisor)
{
if (divisor == 0)
{
throw new DivideByZeroException("除數不能為零");
}
return (double)dividend / divisor;
}
public bool IsEven(int number)
{
return number % 2 == 0;
}
}
在 MyProject.Core.Tests
專案中建立 CalculatorTests.cs
:
using MyProject.Core;
namespace MyProject.Core.Tests;
public class CalculatorTests
{
private readonly Calculator _calculator;
// 建構函式:每個測試都會建立新的 Calculator 實例
public CalculatorTests()
{
_calculator = new Calculator();
}
#region Add 方法測試
[Fact]
public void Add_輸入兩個正數_應回傳正確的和()
{
// Arrange
int a = 5;
int b = 3;
int expected = 8;
// Act
int result = _calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(-5, -3, -8)]
[InlineData(100, -50, 50)]
public void Add_各種數字組合_應回傳正確結果(int a, int b, int expected)
{
// Act
int result = _calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
#endregion
#region Subtract 方法測試
[Fact]
public void Subtract_輸入被減數大於減數_應回傳正數()
{
// Arrange
int minuend = 10;
int subtrahend = 3;
int expected = 7;
// Act
int result = _calculator.Subtract(minuend, subtrahend);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData(10, 3, 7)]
[InlineData(5, 5, 0)]
[InlineData(3, 10, -7)]
[InlineData(0, 5, -5)]
[InlineData(-5, -3, -2)]
public void Subtract_各種數字組合_應回傳正確結果(int a, int b, int expected)
{
// Act
int result = _calculator.Subtract(a, b);
// Assert
Assert.Equal(expected, result);
}
#endregion
#region Multiply 方法測試
[Fact]
public void Multiply_輸入兩個正數_應回傳正確的積()
{
// Arrange
int a = 4;
int b = 5;
int expected = 20;
// Act
int result = _calculator.Multiply(a, b);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData(0, 5, 0)] // 零乘以任何數都是零
[InlineData(1, 7, 7)] // 一乘以任何數都是該數
[InlineData(-3, 4, -12)] // 負數乘以正數
[InlineData(-2, -5, 10)] // 負數乘以負數
public void Multiply_特殊情況_應回傳正確結果(int a, int b, int expected)
{
// Act
int result = _calculator.Multiply(a, b);
// Assert
Assert.Equal(expected, result);
}
#endregion
#region Divide 方法測試
[Fact]
public void Divide_輸入有效的被除數和除數_應回傳正確商()
{
// Arrange
int dividend = 10;
int divisor = 2;
double expected = 5.0;
// Act
double result = _calculator.Divide(dividend, divisor);
// Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData(10, 2, 5.0)]
[InlineData(7, 2, 3.5)]
[InlineData(-10, 2, -5.0)]
[InlineData(10, -2, -5.0)]
[InlineData(0, 5, 0.0)]
public void Divide_各種有效輸入_應回傳正確結果(int dividend, int divisor, double expected)
{
// Act
double result = _calculator.Divide(dividend, divisor);
// Assert
Assert.Equal(expected, result, precision: 2); // 指定精確度
}
[Fact]
public void Divide_除數為零_應拋出DivideByZeroException()
{
// Arrange
int dividend = 10;
int divisor = 0;
// Act & Assert
DivideByZeroException exception = Assert.Throws<DivideByZeroException>(
() => _calculator.Divide(dividend, divisor)
);
// 驗證例外訊息
Assert.Equal("除數不能為零", exception.Message);
}
#endregion
#region IsEven 方法測試
[Theory]
[InlineData(2, true)]
[InlineData(4, true)]
[InlineData(0, true)] // 零是偶數
[InlineData(-2, true)] // 負偶數
[InlineData(1, false)]
[InlineData(3, false)]
[InlineData(-1, false)] // 負奇數
[InlineData(-3, false)]
public void IsEven_各種整數輸入_應正確判斷奇偶(int number, bool expected)
{
// Act
bool result = _calculator.IsEven(number);
// Assert
Assert.Equal(expected, result);
}
#endregion
}
MyProject.Core 類別庫專案與 MyProject.Core.Tests 測試專案
透過 .NET CLI 執行測試
# 先進行建置
dotnet build
# 執行測試
dotnet test
執行結果
在 VS Code 裡的 Test Explorer 執行測試
測試執行完成
在 VS Code 裡,要安裝
C# Dev Kit
xUnit 提供了基本的 Assert 方法來驗證測試結果:
[Fact]
public void Assert_基本相等性驗證()
{
// 基本相等比較
Assert.Equal(5, 2 + 3);
Assert.NotEqual(5, 2 + 2);
// 字串比較
Assert.Equal("Hello", "Hello");
Assert.Equal("hello", "HELLO", ignoreCase: true);
// 物件比較
var user1 = new User { Name = "John", Age = 30 };
var user2 = new User { Name = "John", Age = 30 };
Assert.Equal(user1, user2); // 需要實作 IEquatable<T> 或覆寫 Equals
// 參考比較
Assert.Same(user1, user1); // 同一個物件實例
Assert.NotSame(user1, user2); // 不同物件實例
}
[Fact]
public void Assert_數值比較驗證()
{
// 數值範圍比較
Assert.InRange(5, 1, 10); // 5 在 1 到 10 之間
Assert.NotInRange(15, 1, 10); // 15 不在 1 到 10 之間
// 浮點數比較(處理精度問題)
Assert.Equal(0.1 + 0.2, 0.3, precision: 1); // 保留 1 位小數比較
// 布林值比較
Assert.True(5 > 3);
Assert.False(5 < 3);
}
[Fact]
public void Assert_集合驗證()
{
var numbers = new[] { 1, 2, 3, 4, 5 };
var emptyList = new List<int>();
// 集合內容驗證
Assert.Contains(3, numbers); // 包含特定元素
Assert.DoesNotContain(6, numbers); // 不包含特定元素
// 集合狀態驗證
Assert.Empty(emptyList); // 空集合
Assert.NotEmpty(numbers); // 非空集合
// 集合大小驗證
Assert.Single(new[] { "only one" }); // 只有一個元素
// 集合相等比較
var expected = new[] { 1, 2, 3, 4, 5 };
Assert.Equal(expected, numbers); // 順序和內容都相同
// 集合包含比較
Assert.Subset(new HashSet<int> { 1, 2 }, new HashSet<int> { 1, 2, 3 }); // 子集合
}
[Fact]
public void Assert_字串驗證()
{
string text = "Hello, World!";
// 字串包含驗證
Assert.Contains("World", text);
Assert.DoesNotContain("Universe", text);
// 字串開始結束驗證
Assert.StartsWith("Hello", text);
Assert.EndsWith("World!", text);
// 字串比對模式
Assert.Matches(@"H\w+", text); // 正規表達式比對
Assert.DoesNotMatch(@"\d+", text); // 不符合正規表達式
// 空字串驗證
Assert.Empty("");
Assert.NotEmpty(text);
}
[Fact]
public void Assert_例外驗證()
{
var calculator = new Calculator();
// 驗證特定例外被拋出
var exception = Assert.Throws<DivideByZeroException>(
() => calculator.Divide(10, 0)
);
Assert.Equal("除數不能為零", exception.Message);
// 驗證特定類型的例外(包含子類型)
Assert.ThrowsAny<Exception>(() => calculator.Divide(10, 0));
// 驗證沒有例外被拋出
var exception2 = Record.Exception(() => calculator.Add(1, 2));
Assert.Null(exception2);
}
[Fact]
public void Assert_Null值驗證()
{
string nullString = null;
string nonNullString = "Hello";
// Null 值驗證
Assert.Null(nullString);
Assert.NotNull(nonNullString);
}
[Fact]
public void Assert_型別驗證()
{
object obj = "This is a string";
// 型別檢查
Assert.IsType<string>(obj); // 精確型別比對
Assert.IsNotType<int>(obj); // 不是特定型別
// 型別相容性檢查(包含繼承關係)
Assert.IsAssignableFrom<object>(obj); // 可以轉換為特定型別
}
測試隔離指的是每個測試方法執行時都應該是獨立的,不會被其他測試的執行結果影響,也不會影響其他測試。這是 FIRST 原則中的 I (Independent)
的核心概念。
public class CalculatorTests
{
private readonly Calculator _calculator;
private int _testCounter = 0;
// 每個測試方法執行前都會建立新的測試類別實例
public CalculatorTests()
{
_calculator = new Calculator();
_testCounter = 0; // 每個測試都重新初始化
Console.WriteLine($"建構函式執行:{DateTime.Now:HH:mm:ss.fff}");
}
[Fact]
public void Test1_會有獨立的實例()
{
_testCounter++;
Assert.Equal(1, _testCounter); // 永遠會是 1
}
[Fact]
public void Test2_也會有獨立的實例()
{
_testCounter++;
Assert.Equal(1, _testCounter); // 也永遠會是 1,不會受 Test1 影響
}
}
xUnit 的隔離機制:
IDisposable
,Dispose()
會在每個測試後執行[TestFixture]
public class CalculatorTests
{
private Calculator _calculator;
private int _testCounter = 0; // 這個值會在測試間共享!
// 預設情況下,建構函式只執行一次
public CalculatorTests()
{
Console.WriteLine($"建構函式執行:{DateTime.Now:HH:mm:ss.fff}");
}
[SetUp]
public void SetUp()
{
_calculator = new Calculator();
// _testCounter 不會重置!這可能造成問題
}
[Test]
public void Test1_可能會影響其他測試()
{
_testCounter++;
Assert.AreEqual(1, _testCounter); // 第一次執行會通過
}
[Test]
public void Test2_可能受到Test1影響()
{
_testCounter++;
Assert.AreEqual(1, _testCounter); // 如果 Test1 先執行,這個會失敗!
}
}
要在 NUnit 中實現完全隔離,需要額外設定:
[TestFixture]
public class CalculatorTestsWithIsolation
{
private Calculator _calculator;
private int _testCounter;
[SetUp]
public void SetUp()
{
_calculator = new Calculator();
_testCounter = 0; // 手動重置所有狀態
}
[Test]
public void Test1_現在是隔離的()
{
_testCounter++;
Assert.AreEqual(1, _testCounter);
}
[Test]
public void Test2_也是隔離的()
{
_testCounter++;
Assert.AreEqual(1, _testCounter);
}
}
[TestClass]
public class CalculatorTests
{
private Calculator _calculator;
private int _testCounter = 0; // 同樣會在測試間共享
// 建構函式只執行一次(預設行為)
public CalculatorTests()
{
Console.WriteLine($"建構函式執行:{DateTime.Now:HH:mm:ss.fff}");
}
[TestInitialize]
public void TestInitialize()
{
_calculator = new Calculator();
// 需要手動重置狀態
_testCounter = 0;
}
[TestMethod]
public void Test1_需要手動管理隔離()
{
_testCounter++;
Assert.AreEqual(1, _testCounter);
}
[TestMethod]
public void Test2_也需要手動管理隔離()
{
_testCounter++;
Assert.AreEqual(1, _testCounter);
}
}
這邊以一個實際的例子來說明為什麼隔離很重要:
沒有隔離的問題 (NUnit/MSTest 預設行為)
[TestFixture] // NUnit
public class OrderServiceBadTests
{
private OrderService _orderService;
private List<Order> _orders = new List<Order>(); // 共享狀態!
public OrderServiceBadTests()
{
_orderService = new OrderService();
}
[Test]
public void CreateOrder_應該新增訂單到清單()
{
var order = new Order { Id = 1, Amount = 100 };
_orderService.CreateOrder(order);
_orders.Add(order);
Assert.AreEqual(1, _orders.Count);
}
[Test]
public void DeleteOrder_應該從清單移除訂單()
{
// 如果前一個測試先執行,_orders 已經有一筆資料了!
var order = new Order { Id = 2, Amount = 200 };
_orders.Add(order);
_orderService.DeleteOrder(order.Id);
_orders.Remove(order);
Assert.AreEqual(0, _orders.Count); // 可能失敗,因為前一個測試的資料還在
}
}
xUnit 自動提供的隔離
public class OrderServiceGoodTests
{
private readonly OrderService _orderService;
private readonly List<Order> _orders; // 每個測試都是新的實例
public OrderServiceGoodTests()
{
_orderService = new OrderService();
_orders = new List<Order>(); // 每個測試都重新建立
}
[Fact]
public void CreateOrder_應該新增訂單到清單()
{
var order = new Order { Id = 1, Amount = 100 };
_orderService.CreateOrder(order);
_orders.Add(order);
Assert.Equal(1, _orders.Count); // 永遠通過
}
[Fact]
public void DeleteOrder_應該從清單移除訂單()
{
// _orders 永遠是空的新清單
var order = new Order { Id = 2, Amount = 200 };
_orders.Add(order);
_orderService.DeleteOrder(order.Id);
_orders.Remove(order);
Assert.Equal(0, _orders.Count); // 永遠通過
}
}
你可能會想:「每個測試都建立新實例,不會很慢嗎?」
實際上:
IClassFixture<T>
來共享資源
// 當建立成本很高時,使用 IClassFixture
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public DatabaseFixture()
{
// 昂貴的資源建立(只執行一次)
ConnectionString = CreateTestDatabase();
}
public void Dispose()
{
// 清理資源
}
}
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture; // 所有測試共享同一個 fixture
}
}
這就是為什麼在比較表中,我將 xUnit 標記為「預設隔離」,而 NUnit 和 MSTest 標記為「需要設定」的原因。xUnit 的設計哲學就是「讓正確的事情變得容易」!
測試總管
執行測試
- 執行所有測試:Ctrl+R, A
- 執行選取的測試:Ctrl+R, T
- 偵錯測試:Ctrl+R, Ctrl+T
測試結果檢視
# 執行所有測試
dotnet test
# 執行特定專案的測試
dotnet test tests/MyProject.Core.Tests/
# 執行測試並顯示詳細輸出
dotnet test --logger "console;verbosity=detailed"
# 執行測試並產生覆蓋率報告
dotnet test --collect:"XPlat Code Coverage"
# 執行符合特定名稱模式的測試
dotnet test --filter "Add"
dotnet test --filter "FullyQualifiedName~Calculator"
[Fact]
public void Debug_示範測試偵錯技巧()
{
// 在測試中設定中斷點
var calculator = new Calculator();
// 使用 System.Diagnostics.Debugger.Break() 強制中斷
System.Diagnostics.Debugger.Break();
var result = calculator.Add(1, 2);
// 在中斷點處檢查變數值
// 注意:避免使用 Console.WriteLine,因為輸出不會顯示在測試結果中
// xUnit 需要使用 ITestOutputHelper 來輸出偵錯資訊(進階主題)
Assert.Equal(3, result);
}
// 好的命名:說明測試什麼、在什麼情況下、期望什麼結果
[Fact]
public void Add_輸入兩個正整數_應回傳正確的和()
[Theory]
[InlineData(-1)]
[InlineData(0)]
public void IsPositive_輸入非正數_應回傳False(int number)
// 不好的命名:不明確、無法理解測試意圖
[Fact]
public void TestAdd()
[Fact]
public void Test1()
public class CalculatorTests
{
private readonly Calculator _calculator;
public CalculatorTests()
{
_calculator = new Calculator();
}
// 使用 #region 組織相關測試
#region Add 方法測試
[Fact]
public void Add_基本功能測試() { }
[Theory]
[InlineData(/* 邊界值測試資料 */)]
public void Add_邊界值測試() { }
#endregion
#region Exception 測試
[Fact]
public void Add_異常情況測試() { }
#endregion
}
// 錯誤:測試間有相依性
public class BadTests
{
private static int _counter = 0;
[Fact]
public void Test1_會修改共用狀態()
{
_counter++;
Assert.Equal(1, _counter);
}
[Fact]
public void Test2_依賴前一個測試的結果() // 這個測試可能失敗
{
Assert.Equal(1, _counter); // 如果 Test1 沒有先執行就會失敗
}
}
// 正確:每個測試都是獨立的
public class GoodTests
{
[Fact]
public void Test1_獨立的測試()
{
int counter = 0;
counter++;
Assert.Equal(1, counter);
}
[Fact]
public void Test2_也是獨立的測試()
{
int counter = 0;
counter++;
Assert.Equal(1, counter);
}
}
// 錯誤:使用 Console.WriteLine 輸出偵錯資訊
[Fact]
public void BadDebug_使用Console輸出偵錯資訊()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
// 這個輸出不會顯示在測試結果中!
Console.WriteLine($"計算結果: {result}");
Assert.Equal(3, result);
}
// 正確:使用 ITestOutputHelper 輸出偵錯資訊
public class CalculatorTestsWithOutput
{
private readonly Calculator _calculator;
private readonly ITestOutputHelper _output;
public CalculatorTestsWithOutput(ITestOutputHelper output)
{
_calculator = new Calculator();
_output = output;
}
[Fact]
public void GoodDebug_使用ITestOutputHelper輸出偵錯資訊()
{
var result = _calculator.Add(1, 2);
// 這個輸出會顯示在測試結果中
_output.WriteLine($"計算結果: {result}");
Assert.Equal(3, result);
}
}
注意:上述範例中的
ITestOutputHelper
是 xUnit 提供的測試輸出介面,用於在測試中輸出偵錯資訊。關於ITestOutputHelper
的詳細使用方法與進階應用,我們將在後續的主題中深入介紹。
為什麼 Console.WriteLine 不好?
正確的偵錯方式:
// 錯誤:測試設定過於複雜
[Fact]
public void ComplexTest_難以理解和維護()
{
// 大量的設定程式碼
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.test.json")
.Build();
var services = new ServiceCollection();
services.AddDbContext<TestDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
services.AddScoped<IUserService, UserService>();
var provider = services.BuildServiceProvider();
var userService = provider.GetService<IUserService>();
// 實際測試邏輯淹沒在設定中
var result = userService.GetUser(1);
Assert.NotNull(result);
}
// 正確:簡化測試,專注於要驗證的邏輯
[Fact]
public void GetUser_輸入有效ID_應回傳使用者()
{
// 使用 Mock 簡化依賴性
var mockRepository = Substitute.For<IUserRepository>();
mockRepository.GetById(1).Returns(new User { Id = 1, Name = "John" });
var userService = new UserService(mockRepository);
var result = userService.GetUser(1);
Assert.NotNull(result);
Assert.Equal("John", result.Name);
}
在今天的實戰練習後,請思考:
老派工程師的挑戰:
嘗試為你目前專案中的一個核心類別寫完整的測試,包含:
明天我們將深入xUnit 進階功能與測試資料管理,包括:
框架的選擇往往決定了團隊未來幾年的開發體驗。
當你開始使用 xUnit 時,你會發現寫測試變得更加自然,因為框架本身就鼓勵最佳實踐。每個測試的隔離性、Theory 的參數化測試、清晰的 Assert API —— 這些都讓測試不再是負擔,而是開發流程的自然延伸。
記住:好的工具不會讓你的程式碼變好,但會讓你更容易寫出好程式碼。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」系列的第二天。明天我們將深入探索 xUnit 的進階功能與測試資料管理技巧!